Skip to content

feat: propagate structured CipherStash auth refusals - #894

Merged
tobyhede merged 5 commits into
mainfrom
toby/cip-3727-propagate-cts-usage-limit-refusals
Aug 31, 2026
Merged

feat: propagate structured CipherStash auth refusals#894
tobyhede merged 5 commits into
mainfrom
toby/cip-3727-propagate-cts-usage-limit-refusals

Conversation

@tobyhede

@tobyhede tobyhede commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Propagate structured CipherStash authentication refusals through protect-ffi, Stack, Next.js, and the stash CLI.

stack-auth remains the source of truth for diagnostic codes, messages, instructions, and links. Downstream layers preserve those diagnostics rather than rebuilding or duplicating guidance.

Changes

  • Mark the protect-ffi auth error variants as transparent miette diagnostics and expose their code, authCode, help, and url fields across native and WASM bindings, including bulk item failures.
  • Preserve upstream diagnostic messages and structured guidance throughout @cipherstash/stack operations and client initialization.
  • Classify direct CTS 402 responses consistently in LockContext.identify() and @cipherstash/nextjs: known or legacy responses become typed refusals, while unknown cs_code values are left unclassified.
  • Update @cipherstash/auth to 0.44.0 and render its guidance in stash auth login and stash env, including machine-readable terminal refusal codes.
  • Add regression coverage for native/WASM propagation, operation and bulk failures, direct CTS parsing, and CLI output.

Verification

  • protect-ffi Rust: 312 passed
  • protect-ffi JavaScript: 105 passed
  • Stack targeted suites: 90 passed
  • Next.js: 25 passed
  • CLI targeted suites: 81 passed
  • Repository script guards: 816 passed, 1 skipped
  • git diff --check

The full Stack suite's live-service cases require CipherStash credentials. The CLI declaration build also requires the generated @cipherstash/eql/sql artifact.

Related

Refs cipherstash/cipherstash-suite#2120

@changeset-bot

changeset-bot Bot commented Aug 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d2a8f08

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
Name Type
@cipherstash/nextjs Minor
@cipherstash/protect-ffi Minor
@cipherstash/stack Minor
stash Minor
@cipherstash/stack-drizzle Minor
@cipherstash/stack-supabase Minor
@cipherstash/ffi-integration-tests Patch
@cipherstash/bench Patch
@cipherstash/stack-prisma Minor
@cipherstash/test-kit Patch
@cipherstash/basic-example Patch
@cipherstash/prisma-example Patch
@cipherstash/e2e Patch
@cipherstash/wizard Minor
@cipherstash/protect-ffi-darwin-x64 Minor
@cipherstash/protect-ffi-darwin-arm64 Minor
@cipherstash/protect-ffi-win32-x64-msvc Minor
@cipherstash/protect-ffi-linux-x64-gnu Minor
@cipherstash/protect-ffi-linux-arm64-gnu Minor
@cipherstash/protect-ffi-linux-x64-musl Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@tobyhede
tobyhede marked this pull request as ready for review August 17, 2026 01:35
@tobyhede
tobyhede requested a review from a team as a code owner August 17, 2026 01:35
Comment thread packages/stack/src/encryption/helpers/auth-failure.ts Outdated
Comment thread packages/protect-ffi/crates/protect-ffi/src/lib.rs Outdated

@coderdan coderdan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest, I think this PR is the wrong approach. It's doing too much heavy lifting. The error type in stack_auth already implements miette::Diagnostic, So I think we can just add #[diagnostic(transparent)] to the AuthError enum variant on the protect_ffi Error. The stack/protect-ffi layer should be really thin.

StackAuth should include a code, message and instructions on the diagnostic for when CTS returns a 402. Then any consumer of stack_auth, including Proxy would get the same response.

@tobyhede
tobyhede force-pushed the toby/cip-3727-propagate-cts-usage-limit-refusals branch 3 times, most recently from a76bb7e to 82b3ad1 Compare August 26, 2026 04:00
@tobyhede tobyhede changed the title feat: surface CTS usage-limit refusals with the dashboard remedy (CIP-3727) feat: surface a CipherStash usage-limit refusal with a code and a remedy Aug 26, 2026
@tobyhede
tobyhede requested a review from coderdan August 26, 2026 04:04
@tobyhede

Copy link
Copy Markdown
Contributor Author

Force-pushed a history rewrite, and it changed more than commit hashes — flagging it since the diff itself did not move.

Four commits became three, split by layer rather than by chronology:

feat(protect-ffi): carry the auth taxonomy across the JS boundary
feat(stack): surface a CipherStash billing refusal as something you can act on
feat(cli,nextjs): report a CipherStash billing refusal as one

What went away was churn, not content: a commit that guessed at the wire format and a later one that corrected it, a remedy map written to override upstream and a later flip to prefer upstream, two rounds of review fixes, and a formatting commit forced by a Biome bump on main.

The rewrite is content-neutral — the tree hash is byte-identical before and after (44b4f9a2c851404ad25632e70dd736c5f99baca1), so everything CI had already verified still holds. Each commit also stands on its own: the first passes 332 Rust and 105 JS tests alone, the second 1220 stack tests alone.

The title and body are rewritten too. The old ones described the pre-0.42.3 design — they claimed the dashboard link was folded into message (it travels on url now), quoted remedy text this branch no longer authors, and said the work was inert pending a pin move that has since happened. Both also referenced an internal issue tracker ID, which should not be on a public repo.

@tobyhede
tobyhede requested a review from freshtonic August 26, 2026 04:04
@tobyhede
tobyhede force-pushed the toby/cip-3727-propagate-cts-usage-limit-refusals branch from d61f40f to 6bd1bb5 Compare August 31, 2026 00:08
@tobyhede tobyhede changed the title feat: surface a CipherStash usage-limit refusal with a code and a remedy feat: propagate structured CipherStash auth refusals Aug 31, 2026
Comment thread packages/stack/src/identity/index.ts
@coderdan

Copy link
Copy Markdown
Contributor

The #[diagnostic(transparent)] change is exactly what I was after, and dropping AUTH_REMEDIES in favour of stack-auth's own help/url settles my earlier comment properly — the remedy prose now has one owner and Proxy gets it for free. Approving.

One thing to pick up separately, not a blocker.

The two direct-HTTP CTS paths can't reach the diagnostics this PR adds.

readCtsRefusal exists twice — packages/stack/src/identity/index.ts:57 and packages/nextjs/src/cts/index.ts:57. The duplication itself I'm not worried about: the stack copy is only reachable from LockContext.identify(), which is already @deprecated, so it goes at the next major and one copy remains.

What survives the deprecation is the asymmetry:

protect-ffi path:  authCode + help + url
nextjs HTTP path:  authCode only

getCtsToken (packages/nextjs/src/index.ts:52) carries no deprecation — it's the Clerk middleware path. So a Clerk user who hits USAGE_LIMIT_EXCEEDED gets the code and CTS's own error_description, but not https://dashboard.cipherstash.com/billing, which is the link this PR exists to deliver. The docstring on ctsRefusalError explains why (never enters the Rust client, and @cipherstash/stack doesn't expose the remedy copy on a public subpath). That reasoning is correct, but it currently reads as a known gap rather than a decision.

Worth flagging before anyone reaches for the obvious fix: @cipherstash/nextjs depends on jose and nothing else — no CipherStash packages at all. So "just import the lookup from @cipherstash/auth" is more expensive than it sounds, since that package pulls per-platform native binaries into what is otherwise a dependency-free middleware package.

Options, roughly in my order of preference:

  1. Have @cipherstash/auth export the code → { help, url } table as plain JS with no binary, so both HTTP paths read the same upstream source the Rust client does.
  2. Leave the gap and make it an explicit decision in the docstring — authCode alone is enough for a caller to look up its own guidance, and this keeps @cipherstash/nextjs dependency-free.
  3. Copy the strings into nextjs — which is what this PR just correctly removed from stack, so: no.

Happy for this to land as-is and for that to be a follow-up.

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes. The engineering is sound — the propagation path is correct where it is live, the Result contract and EncryptionErrorTypes strings are untouched, the test work is unusually rigorous, and CI is fully green including the credentialed live suite. What blocks is prose that ships to customers stating as current behaviour something the pinned dependency provably cannot do yet, plus a pair of docblocks describing a mechanism this PR's own rework deleted. All fixes are text-level.

1. BLOCKING — the CLI half is dormant, but the skills and changeset say it's live

The taxonomy (USAGE_LIMIT_EXCEEDED, ORG_NOT_PROVISIONED, cs_code) ships in stack-auth 0.42.3, published to crates.io 2026-08-26. The npm @cipherstash/auth this PR pins is 0.43.0, published 2026-08-12 — the latest available, and structurally two weeks too old to contain it. I verified both registry timestamps directly, and a byte-search of the published 0.43.0 binaries (darwin-arm64 .node and the wasm build) finds zero occurrences of the taxonomy codes or cs_code, with NOT_AUTHENTICATED present as the positive control; the 0.43.0 AuthFailure union in index.d.ts lacks both types too.

So today, a real CTS 402 during stash env renewal or stash auth login arrives from the auth binding as a pre-taxonomy type, falls through to session_invalid + the login hint — the exact wrong-advice loop this PR exists to kill. Every CLI test mocks @cipherstash/auth, so the suites pin the rendering, not the live path. (The native @cipherstash/stack entry, LockContext.identify(), and @cipherstash/nextjs are live — protect-ffi embeds stack-auth 0.42.3, and the identify/nextjs paths parse the 402 themselves.)

The tests already know this — packages/cli/src/commands/auth/__tests__/failure.test.ts says the lookups "start returning the terminal answer the day the dependency ships the code". The customer-facing text doesn't:

  • skills/stash-cli/SKILL.md: "That case reports usage_limit_exceeded…", "fails with USAGE_LIMIT_EXCEEDED on the --json stream"
  • skills/stash-auth/SKILL.md: "On the CLI, stash env reports these under their own codes…"
  • .changeset/usage-limit-refusal-guidance.md: "stash auth login and stash env now consume @cipherstash/auth 0.43.0's typed failures"

Either gate the PR on an auth release carrying the taxonomy and bump the pin, or reword these three to conditional/future phrasing. Skills land in customer repos; per AGENTS.md a wrong sentence there is a wrong line of code.

2. Dangling AuthDiagnosticRelay + false "verbatim relay" claim in shipped TSDoc

  • packages/protect-ffi/src/types.ts and src/index.types.test.ts both say "see AuthDiagnosticRelay in crates/protect-ffi/src/lib.rs" — no such item exists; it's a leftover from the pre-rework design (the rework moved to #[diagnostic(transparent)] and deleted the relay).
  • packages/protect-ffi/src/errors.ts (and the tracked dist/wasm/errors.d.ts) claim a config.authStrategy failure's help/url/type are relayed "verbatim — not values re-derived from the stack-auth enum". The implementation does the opposite: neon_failure_to_auth_error drops the reserved keys and reconstructs via AuthError::from_error_code, whose help/url come from the enum's fixed #[diagnostic] attributes, and unknown codes collapse to CUSTOM. A strategy's own remedy text never survives. The type-level test only pins that the type accepts the fields, as its own docblock admits.

3. Minor text fixes

  • packages/cli/src/commands/auth/failure.ts: the comment claiming byte-identity with TERMINAL_PUNCTUATION in packages/stack/src/encryption/helpers/auth-failure.ts — that file contains no such constant and no message-joining; no guard compares them. Rework leftover.
  • Test narration in identify-cts-refusal.test.ts (and the nextjs twin) says the two legacy codes are "read from error"; neither classifier reads error at all — absent cs_code defaults unconditionally. Behaviour correct, narration wrong.
  • Worth one sentence somewhere: auth login --json emits USAGE_LIMIT_EXCEEDED while env --json emits usage_limit_exceeded — each consistent with its command's convention, but an agent consuming both streams matches two spellings of one condition.

Verified clean (no need to re-argue these)

Result contract and error-type strings untouched, all new fields optional/additive across every error shape including bulk per-row items; #[diagnostic(transparent)] cannot leak a foreign code into ProtectErrorCode (checked every crate in the chain for #[diagnostic(code(...))] — none); all 20 operation mappers converted and parameter-tested, with mocks made partial precisely so the real isProtectErrorCode runs; the wasm-inline readErrorCode change closes a genuine accepts-any-string type lie; no native specifier leaks into the wasm bundle; skills touched match the AGENTS.md map and nothing in the untouched skills became false; four changesets present and correctly leveled (modulo the finding-1 sentence); Rust test literals match stack-auth 0.42.3's attributes verbatim; no plaintext/secret logging; CI green including cargo checks and the live integration suite. Also worth saying out loud since the body undersells it: packages/nextjs had no test script at all — its suite was dead until this PR wired it up and repaired it. Good change.

Happy to re-review as soon as the finding-1 wording (or the pin) and the finding-2 docblocks are settled.

@tobyhede
tobyhede force-pushed the toby/cip-3727-propagate-cts-usage-limit-refusals branch from 6bd1bb5 to 7018cca Compare August 31, 2026 03:48
@tobyhede
tobyhede force-pushed the toby/cip-3727-propagate-cts-usage-limit-refusals branch from 068196f to d2a8f08 Compare August 31, 2026 03:56
@tobyhede
tobyhede requested a review from freshtonic August 31, 2026 04:40

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at d2a8f08. Every finding from my previous review is resolved, and I re-verified the load-bearing one against the registry rather than the prose:

  1. The dormancy is gone — the pin now points at a binding that really carries the taxonomy. @cipherstash/auth 0.44.0 (published today, 03:43Z) is pinned across all seven catalog names together, per the supply-chain rule. I unpacked the published auth-darwin-arm64@0.44.0 binary and found USAGE_LIMIT_EXCEEDED, ORG_NOT_PROVISIONED, and cs_code present (with NOT_AUTHENTICATED as the positive control), and 0.44.0's index.d.ts declares both new members on the AuthFailure union. The changeset and skills' present-tense claims ("now consume 0.44.0's typed failures") are now true statements about the shipping dependency, and CI's credentialed integration suite is green on the bump.
  2. The "verbatim relay" claim is rewritten to match the implementation (reconstruction from type + message + variant payload; known codes get the variant's guidance, unknown collapse to CUSTOM, caller help/url not forwarded) — in errors.ts, types.ts, the type test, and the tracked dist/wasm/errors.d.ts. Both dangling AuthDiagnosticRelay references are gone.
  3. The TERMINAL_PUNCTUATION byte-identity comment is replaced with the truthful local-presentation note, and the "reads off error" test narrations now say what the classifiers actually do (default to the usage limit).
  4. The casing split even got documented — skills/stash-cli now tells agents USAGE_LIMIT_EXCEEDED (login) and usage_limit_exceeded (env) are one condition. That was my optional item; nice to see it land.

The nextjs boundary rationale (don't pull platform binaries into middleware for a string lookup) is also better argued than before. Good rework all round.

@tobyhede
tobyhede merged commit dcc8c95 into main Aug 31, 2026
35 checks passed
@tobyhede
tobyhede deleted the toby/cip-3727-propagate-cts-usage-limit-refusals branch August 31, 2026 07:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants